Skip to content

RubyLLM phased migration feature-flagged - #775

Draft
fluxgravity wants to merge 7 commits into
AllYourBot:mainfrom
strivedi183:rubyllm-phased-migration-with-feature-flag
Draft

RubyLLM phased migration feature-flagged#775
fluxgravity wants to merge 7 commits into
AllYourBot:mainfrom
strivedi183:rubyllm-phased-migration-with-feature-flag

Conversation

@fluxgravity

@fluxgravity fluxgravity commented Aug 18, 2026

Copy link
Copy Markdown

What

Phases 1–6 of the RubyLLM migration plan — a feature-flagged migration from three per-provider SDK gems (ruby-openai, ruby-anthropic, gemini-ai) to the unified ruby_llm gem. Phases 1–5 land the new backend behind a flag; Phase 6 flips the flag on by default outside the test environment. Each commit maps to one plan phase; they can be split into separate PRs later if narrower review is desired.

Phase 1 — Scaffolding (406b850)

  • Adds ruby_llm ~> 1.16.0 (patchlocked; existing SDK gems untouched)
  • Adds use_ruby_llm feature flag (off by default) and initializer with intentional dummy keys
  • Empty AIBackend::RubyLLM shell with supports_driver? → false
  • APIService#ai_backend guard routes through RubyLLM only when flag is on AND driver is supported
  • Fixes latent bug where preference writes clobbered unrelated keys (now deep-merges)

Phase 2 — OpenAI text-only chat (a04415c)

  • supports_driver? returns true for openai only
  • get_oneoff_message / stream_next_conversation_message full overrides on RubyLLM's Chat API
  • stream_handler yields chunks, captures tokens, maps RubyLLM errors to ConfigurationError/RateLimitError
  • Text-only preceding_conversation_messages, tool messages skipped
  • Groq (openai driver, non-canonical URL) via openai_api_base override
  • Full TestClient::RubyLLM::Chat fake

Phase 3 — Anthropic + Gemini text chat (2c917bc)

  • supports_driver? now true for all three drivers
  • provider_slug / dynamic ruby_llm_context / provider_for_url for per-provider routing and test_execute
  • Anthropic + Gemini streaming and job-level tests

Phase 4 — Image/PDF attachment parity (98d5187)

  • Multimodal input via RubyLLM::Content.new(text, attachments) with RubyLLM::Attachment (ActiveStorage-native)
  • PDFs inlined as extracted text via document.extract_pdf_text (no native PDF upload)
  • sanitize_content strips json_of_generated_image on replay
  • Per-driver vision streaming, PDF extraction, mixed image+text, and sanitization tests

Phase 4.5 — Tool-interception pre-check (c478d05)

  • InterceptedTool < ::RubyLLM::Tool, registered per Toolbox tool, whose execute raises ToolCallIntercepted
  • Halts RubyLLM's automatic tool-execution loop so HostedGPT can execute tools itself in Phase 5 — RubyLLM never runs a tool or auto-continues
  • name/description/params_schema stored explicitly (the base class derives name from the class name, collapsing all tools into one entry)
  • tool_interception_test.rb asserts the halt against the pinlocked gem behavior

Phase 5 — Tool/function calling parity (1591e82)

  • stream_next_conversation_message extracts content_tool_calls and formats them via format_tool_calls for downstream GetNextAIMessageJob handling
  • tool_instances maps Toolbox.tools (OpenAI-format descriptors) to InterceptedTool instances — no schema re-derivation
  • InterceptedChat#handle_tool_calls (private override) raises ToolCallIntercepted for any tool call, closing the unknown/hallucinated-name gap that InterceptedTool alone could not halt; InterceptedTool#execute kept as a safety net against upstream rename
  • get_oneoff_message widened with a json: kwarg for the provider-blind autotitle path (appends a JSON-coercion instruction, not raw response_format:, which 400s on Anthropic)
  • Error contract conforms to the unified, provider-blind standard: ConfigurationError < AIBackend::ConfigurationError, RATE_LIMIT_ERRORSFaraday::TooManyRequestsError (mapped at the chat.complete boundary), RateLimitError dropped
  • Groq tool held via instance-level tools_enabled? (URL check) since the class-level supports_tools? gate is bypassed when the flag is on
  • Tests: unit (ruby_llm_test.rb), get_next_ai_message_job_ruby_llm_test.rb, new autotitle_conversation_job_ruby_llm_test.rb, interception specs, and tool-call simulation in TestClient::RubyLLM::Chat

Phase 6 — Flip default on, with a test-env carve-out (eaaa250)

  • Flips use_ruby_llm default on for every non-test environment via hand-rolled ERB (ENV["USE_RUBY_LLM_FEATURE"] || (Rails.env.test? ? "false" : "true")); the test environment stays off so the legacy SDK suite keeps dispatching to the old backends
  • Escape hatches preserved: USE_RUBY_LLM_FEATURE=false, or a per-user preferences[:feature][:use_ruby_llm] = false
  • feature_test.rb updated to pin the env-dependent test-env default (boolean false) plus the user-preference override
  • Optional Toolbox::Image image-generation migration to RubyLLM.context.paint was intentionally skipped — image generation continues through the existing OpenAI delegation
  • No job changes (the unified, provider-blind rescues from Phases 1–5 already handle the RubyLLM error contract)

Not in this PR

No existing backends, tests, or gems are modified. Image generation still runs through the existing OpenAI client path (the optional Phase 6 RubyLLM.context.paint migration was skipped). The old backends, old gems, old test clients, the supports_driver? guard, and the flag itself are removed in Phase 7, which ships as a separate PR (gated on production dogfood signal from Phase 6).

Testing

bin/rails test — 892 runs, 0 failures, 0 errors. New RubyLLM tests opt in via stub_features(use_ruby_llm: true). The test-environment default stays false (the Phase 6 carve-out), so the legacy SDK tests keep dispatching to the old backends and the suite stays green. Non-test environments now route through RubyLLM by default; the plan's remaining dogfood checks (text/images/PDFs/tools/autotitle/cancellation/rate-limit/blank-response parity across all three providers) are manual criteria to confirm before merge and before Phase 7.

@fluxgravity fluxgravity changed the title feat(rubyllm): add Phase 1 scaffolding and fix preferences merge bug RubyLLM phased migration feature-flagged Aug 19, 2026
@fluxgravity
fluxgravity force-pushed the rubyllm-phased-migration-with-feature-flag branch from c7a6644 to 5e39539 Compare August 27, 2026 04:20
Add ruby_llm ~> 1.16.0 gem, feature flag (use_ruby_llm, off by default),
empty AIBackend::RubyLLM shell with supports_driver? returning false,
and TestClient::RubyLLM double. The APIService#ai_backend guard remains
dead code until Phase 2 flips supports_driver? to true.

Also fix a latent bug where preference writes clobbered unrelated
keys -- both UsersController and Settings::PeopleController now
deep-merge incoming preferences instead of replacing the entire
column.

Permit feature: [:use_ruby_llm] in both preference write paths so
the flag is per-user writable (no UI yet).
Implements AIBackend::RubyLLM with streaming, token counting, oneoff
messages, and error mapping for the openai driver. RubyLLM gem's
builder-pattern Chat is used with per-request context keys.

- supports_driver? returns true for "openai" only
- get_oneoff_message and stream_next_conversation_message are full
  overrides using RubyLLM's Chat API
- stream_handler proc yields chunk content, captures input/output
  tokens, and maps RubyLLM errors to ConfigurationError/RateLimitError
- preceding_conversation_messages returns text-only {role, content}
  hashes, skipping tool messages (tool support deferred to Phase 5)
- test_execute mirrors the existing OpenAI pattern with context key
- TestClient::RubyLLM::Chat upgraded with complete/content/ask,
  class-level stubbables (text, blank_response, error_to_raise, tokens)
- 24 backend tests + 8 job integration tests
Extend AIBackend::RubyLLM to rout through all three providers
(OpenAI, Anthropic, Gemini) behind the use_ruby_llm feature flag.

- Add provider_slug to dynamically select provider from api_service.driver
- Update ruby_llm_context to set per-provider API keys via public_send
- Update build_chat to pass provider_slug instead of hardcoded :openai
- Add provider_for_url for test_execute routing
- Expand supports_driver? to return true for all three backends
- Add Anthropic and Gemini streaming tests (backend and job level)
- Fix duplicate use_ruby_llm key in options.yml from rebase
- Fix upstream controller tests for nested preferences params
Extend preceding_conversation_messages to pass multimodal input to
RubyLLM for all three drivers.

- Build RubyLLM::Content with image attachments for messages with documents
- Inline PDF text via document.extract_pdf_text (no native PDF upload)
- Strip json_of_generated_image from assistant content on replay
- Add per-driver vision streaming, PDF extraction, mixed image+text,
  and sanitize_content tests
@fluxgravity
fluxgravity force-pushed the rubyllm-phased-migration-with-feature-flag branch from cc20f15 to 98d5187 Compare September 1, 2026 20:32
Implement Phase 5 of the RubyLLM migration. Closes the unknown-name gap
with a new InterceptedChat (raise ToolCallIntercepted for any tool call)
and maps the unified error contract at the chat.complete boundary, since
RubyLLM raises in its HTTP layer outside the per-chunk stream_handler.
… (Phase 6)

Enable the RubyLLM backend by default for all non-test environments via a
hand-rolled ERB expression in options.yml, while keeping the test-environment
default off so the legacy SDK suite keeps dispatching to the old backends.
The flag stays revertible through USE_RUBY_LLM_FEATURE or a per-user
preference, and the feature test now pins the boolean false default.
strivedi183 added a commit to strivedi183/hostedgpt that referenced this pull request Sep 2, 2026
The panel derives its rows from the driver enum, so OpenRouter, which
rides the openai driver and dispatches by driver plus base URL, could
never earn a row of its own. derived_backend_names now appends
:openrouter_ai_backend after the driver-derived list, keyed by dispatch
identity rather than the borrowed dialect. The choice persists through
the existing settings form with no controller changes. Groq rides the
openai driver too but stays out until RubyLLM support for it is
verified; when it is, Groq joins the same way.

Behavior window: the choice is stored and validated at this commit, but
nothing reads it yet. The row's RubyLLM radio stays disabled until AllYourBot#775
teaches AIBackend::RubyLLM.supports_driver? "openrouter" and gives its
URL-to-provider mapping an openrouter.ai arm, and no runtime consumer
dispatches on the stored choice until AllYourBot#775's consumer resolves choices
by dispatch identity, never driver alone, or ruby_llm?("openai") would
silently read the wrong row. AllYourBot#775 rebases onto this series.

Tests pin both the persist path (round-trip with sibling preferences
intact) and the render: the row shows the OpenRouter label, the SDK
radio, and a disabled RubyLLM radio; the typo guard still rejects
groq_ai_backend, which has no derivation entry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant